/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.lodsve.maven.plugin.javatemplate;

import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Resource;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.apache.maven.shared.filtering.MavenFilteringException;
import org.apache.maven.shared.filtering.MavenResourcesExecution;
import org.apache.maven.shared.filtering.MavenResourcesFiltering;
import org.codehaus.plexus.util.FileUtils;

import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.zip.CRC32;

/**
 * .
 *
 * @author Hulk Sun
 */
public abstract class AbstractGenerateSourcesMojo extends AbstractMojo {
    private static final int CHECKSUM_BUFFER = 4096;

    private int copied = 0;

    /**
     * @return The location of the source directory.
     */
    protected abstract File getSourceDirectory();

    /**
     * @return The location of the output directory.
     */
    protected abstract File getOutputDirectory();

    /**
     * The character encoding scheme to be applied when generate resources.
     */
    @Parameter(defaultValue = "${project.build.sourceEncoding}")
    private String encoding;

    /**
     * Expression preceded with the String won't be interpolated \${foo} will be replaced with ${foo}
     */
    @Parameter(property = "maven.resources.escapeString")
    protected String escapeString;

    /**
     * Set of delimiters for expressions to filter within the resources. These delimiters are specified in the form
     * 'beginToken*endToken'. If no '*' is given, the delimiter is assumed to be the same for start and end. So, the
     * default filtering delimiters might be specified as:
     *
     * <pre>
     * &lt;delimiters&gt;
     *   &lt;delimiter&gt;${*}&lt;/delimiter&gt;
     *   &lt;delimiter&gt;@&lt;/delimiter&gt;
     * &lt;/delimiters&gt;
     * </pre>
     * <p>
     * Since the '@' delimiter is the same on both ends, we don't need to specify '@*@' (though we can).
     */
    @Parameter
    protected List<String> delimiters;

    /**
     * Controls whether the default delimiters are included in addition to those configured {@link #delimiters}. Does
     * not have any effect if {@link #delimiters} is empty when the defaults will be included anyway.
     */
    @Parameter(defaultValue = "true")
    protected boolean useDefaultDelimiters;

    @Parameter(defaultValue = "${session}", required = true, readonly = true)
    private MavenSession session;

    @Parameter(defaultValue = "${project}", required = true, readonly = true)
    private MavenProject project;

    /**
     * Controls whether to overwrite files that are not changed, by default files will not be overwritten
     */
    @Parameter(defaultValue = "false")
    protected boolean overwrite;

    /**
     * Skips POM projects if set to true, which is the default option.
     */
    @Parameter(defaultValue = "true")
    protected boolean skipPoms;

    /**
     * The resources filtering which is used.
     */
    @Component(hint = "default")
    protected MavenResourcesFiltering mavenResourcesFiltering;

    /**
     * {@inheritDoc}
     */
    @Override
    public void execute() throws MojoExecutionException {
        File sourceDirectory = getSourceDirectory();
        if (!preconditionsFulfilled(sourceDirectory)) {
            return;
        }

        // 1 Copy with filtering the given source to temporary dir
        copied = 0;
        File temporaryDirectory = getTemporaryDirectory(sourceDirectory);
        logInfo("Copying files with filtering to temporary directory.");
        logDebug("Temporary directory for filtering is: %s", temporaryDirectory);
        filterSourceToTemporaryDir(sourceDirectory, temporaryDirectory);
        // 2 Copy if needed
        copyDirectoryStructure(temporaryDirectory, getOutputDirectory());
        cleanupTemporaryDirectory(temporaryDirectory);
        if (isSomethingBeenUpdated()) {
            logInfo("Copied %d files to output directory: %s", copied, getOutputDirectory());
        } else {
            logInfo("No files need to be copied to output directory. Up to date: %s", getOutputDirectory());
        }

        // 3 Add that dir to sources
        addSourceFolderToProject(this.project);
        logInfo("Source directory: %s added.", getOutputDirectory());
    }

    /**
     * @return number of copied files.
     */
    protected int countCopiedFiles() {
        return copied;
    }

    private void logInfo(String format, Object... args) {
        if (getLog().isInfoEnabled()) {
            getLog().info(String.format(format, args));
        }
    }

    private void logDebug(String format, Object... args) {
        if (getLog().isDebugEnabled()) {
            getLog().debug(String.format(format, args));
        }
    }

    private boolean isSomethingBeenUpdated() {
        return copied > 0;
    }

    private void cleanupTemporaryDirectory(File temporaryDirectory) throws MojoExecutionException {
        try {
            FileUtils.forceDelete(temporaryDirectory);
        } catch (IOException ex) {
            throw new MojoExecutionException(ex.getMessage(), ex);
        }
    }

    private void filterSourceToTemporaryDir(final File sourceDirectory, final File temporaryDirectory) throws MojoExecutionException {
        List<Resource> resources = new ArrayList<>();
        Resource resource = new Resource();
        resource.setFiltering(true);
        logDebug("Source absolute path: %s", sourceDirectory.getAbsolutePath());
        resource.setDirectory(sourceDirectory.getAbsolutePath());
        resources.add(resource);

        MavenResourcesExecution mavenResourcesExecution = new MavenResourcesExecution(resources, temporaryDirectory, project, encoding, Collections.emptyList(), Collections.emptyList(), session);
        mavenResourcesExecution.setInjectProjectBuildFilters(true);
        mavenResourcesExecution.setEscapeString(escapeString);
        mavenResourcesExecution.setOverwrite(overwrite);
        setDelimitersForExecution(mavenResourcesExecution);
        try {
            mavenResourcesFiltering.filterResources(mavenResourcesExecution);
        } catch (MavenFilteringException e) {
            throw new MojoExecutionException(e.getMessage(), e);
        }
    }

    private void setDelimitersForExecution(MavenResourcesExecution mavenResourcesExecution) {
        // if these are NOT set, just use the defaults, which are '${*}' and '@'.
        if (delimiters != null && !delimiters.isEmpty()) {
            LinkedHashSet<String> delims = new LinkedHashSet<>();
            if (useDefaultDelimiters) {
                delims.addAll(mavenResourcesExecution.getDelimiters());
            }

            for (String delim : delimiters) {
                if (delim == null) {
                    // FIXME: ${filter:*} could also trigger this condition. Need a better long-term solution.
                    delims.add("${*}");
                } else {
                    delims.add(delim);
                }
            }

            mavenResourcesExecution.setDelimiters(delims);
        }
    }

    private void preconditionsCopyDirectoryStructure(final File sourceDirectory, final File destinationDirectory, final File rootDestinationDirectory) throws IOException {
        if (sourceDirectory == null) {
            throw new IOException("source directory can't be null.");
        }

        if (destinationDirectory == null) {
            throw new IOException("destination directory can't be null.");
        }

        if (sourceDirectory.equals(destinationDirectory)) {
            throw new IOException("source and destination are the same directory.");
        }

        if (!sourceDirectory.exists()) {
            throw new IOException("Source directory doesn't exists (" + sourceDirectory.getAbsolutePath() + ").");
        }
    }

    private void copyDirectoryStructure(final File sourceDirectory, final File destinationDirectory) throws MojoExecutionException {
        try {
            File target = destinationDirectory;
            if (!target.isAbsolute()) {
                target = resolve(project.getBasedir(), destinationDirectory.getPath());
            }
            copyDirectoryStructureWithIO(sourceDirectory, target, target);
        } catch (IOException ex) {
            throw new MojoExecutionException(ex.getMessage(), ex);
        }
    }

    private void copyDirectoryStructureWithIO(final File sourceDirectory, final File destinationDirectory, final File rootDestinationDirectory) throws IOException {
        preconditionsCopyDirectoryStructure(sourceDirectory, destinationDirectory, rootDestinationDirectory);
        File[] files = sourceDirectory.listFiles();
        if (files == null) {
            return;
        }
        String sourcePath = sourceDirectory.getAbsolutePath();

        for (File file : files) {
            if (file.equals(rootDestinationDirectory)) {
                // We don't copy the destination directory in itself
                continue;
            }

            String dest = file.getAbsolutePath();

            dest = dest.substring(sourcePath.length() + 1);

            File destination = new File(destinationDirectory, dest);

            if (file.isFile()) {
                destination = destination.getParentFile();

                if (isFileDifferent(file, destination)) {
                    copied++;
                    FileUtils.copyFileToDirectory(file, destination);
                }
            } else if (file.isDirectory()) {
                if (!destination.exists() && !destination.mkdirs()) {
                    throw new IOException("Could not create destination directory '" + destination.getAbsolutePath() + "'.");
                }

                copyDirectoryStructureWithIO(file, destination, rootDestinationDirectory);
            } else {
                throw new IOException("Unknown file type: " + file.getAbsolutePath());
            }
        }
    }

    private File resolve(final File file, final String... subfile) {
        StringBuilder path = new StringBuilder();
        path.append(file.getPath());
        for (String fi : subfile) {
            path.append(File.separator);
            path.append(fi);
        }
        return new File(path.toString());
    }

    private boolean isFileDifferent(final File file, final File directory) throws IOException {
        File targetFile = resolve(directory, file.getName()).getAbsoluteFile();
        return !targetFile.canRead() || getCrc32OfFile(file) != getCrc32OfFile(targetFile);
    }

    private long getCrc32OfFile(final File target) throws IOException {
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(target);
            CRC32 crcMaker = new CRC32();
            byte[] buffer = new byte[CHECKSUM_BUFFER];
            int bytesRead;
            while ((bytesRead = fis.read(buffer)) != -1) {
                crcMaker.update(buffer, 0, bytesRead);
            }
            return crcMaker.getValue();
        } catch (FileNotFoundException ex) {
            close(fis);
            throw new IOException(ex.getLocalizedMessage(), ex);
        } finally {
            close(fis);
        }
    }

    private void close(Closeable is) throws IOException {
        if (is != null) {
            is.close();
        }
    }

    private File getTemporaryDirectory(File sourceDirectory) {
        File basedir = project.getBasedir();
        File target = new File(project.getBuild().getDirectory());
        StringBuilder label = new StringBuilder("templates-tmp");
        CRC32 crcMaker = new CRC32();
        crcMaker.update(sourceDirectory.getPath().getBytes());
        label.append(crcMaker.getValue());
        String subfile = label.toString();
        return target.isAbsolute() ? resolve(target, subfile) : resolve(basedir, target.getPath(), subfile);
    }

    private boolean preconditionsFulfilled(File sourceDirectory) {
        if (skipPoms && "pom".equals(project.getPackaging())) {
            logInfo("Skipping POM project type. Change `skipPoms` to false to run anyway.");
            return false;
        }
        logDebug("source=%s target=%s", sourceDirectory, getOutputDirectory());
        if (!(sourceDirectory != null && sourceDirectory.exists())) {
            logInfo("Requested directory '%s' not added since it does not exist.", sourceDirectory);
            return false;
        }
        return true;
    }

    /**
     * @param mavenProject {@link MavenProject}
     */
    protected abstract void addSourceFolderToProject(MavenProject mavenProject);
}
